[TrimmableTypeMap] Match n_* callback boolean/char to each binding's real signature#11802
[TrimmableTypeMap] Match n_* callback boolean/char to each binding's real signature#11802simonrozsival wants to merge 9 commits into
Conversation
…ck refs Fixes #11773. The trimmable typemap generator emits, for each Java peer marshal method, a UCO forwarder that calls the MCW-generated `n_*` callback via a cross-assembly MemberRef. The MemberRef signature was built by `EncodeClrTypeForCallback`, which encoded a JNI boolean (`Z`) parameter/return as `System.SByte`. The real MCW `n_*` callbacks declare their JNI boolean as `System.Boolean` (e.g. `AndroidX.Fragment.App.FragmentManager.IOnBackStackChangedListener. n_OnBackStackChangeStarted_..._Z(IntPtr, IntPtr, IntPtr, bool)`). Because `System.SByte != System.Boolean` in metadata, the emitted MemberRef could not be bound to the real method, so ILC reported: ILC: Method '..._Proxy.n_OnBackStackChangeStarted_uco_*' will always throw because: Missing method 'Void ...n_OnBackStackChangeStarted_...(..., SByte)' and replaced the forwarder body with a throw -- breaking every binding/AndroidX interface listener callback that has a boolean parameter (and any boolean- returning callback) under NativeAOT. This was misdiagnosed as a trimming/preserve-edge problem. It is not: a rooted cross-assembly call to a non-public static method -- including one declared on an interface -- is preserved by ILC. The failure is purely a signature mismatch in the generated MemberRef. Reproduced minimally by emitting an `sbyte` MemberRef against a `bool` method and re-running ilc, which produces the exact "will always throw / Missing method ... SByte" diagnostic from the issue; switching the MemberRef to `bool` resolves and runs. The fix encodes JNI boolean as `System.Boolean` for the callback MemberRef. The UCO entry point keeps `byte` (blittable, unsigned) for the `[UnmanagedCallersOnly]` ABI; only the managed `n_*` MemberRef changes. This matches the constructor path, which already references managed boolean parameters as `System.Boolean`. Passing the UCO `byte` argument to the `bool` parameter is valid IL (both are stack-int32; JNI jboolean is always 0/1), mirroring the legacy LLVM-IR/marshal-methods path which also targets the real `bool` `n_*` callbacks. Four unit tests had codified the incorrect `sbyte` expectation; they are updated to assert `System.Boolean` for the callback signature while keeping `System.Byte` for the UCO ABI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes NativeAOT failures in the TrimmableTypeMap generator where interface listener callbacks involving JNI boolean (Z) could not bind to the MCW-generated n_* methods due to a MemberRef signature mismatch (System.SByte vs System.Boolean). By encoding callback booleans as System.Boolean (while keeping the UCO ABI as byte), the generated cross-assembly MemberRefs correctly resolve and avoid the ILC “will always throw / Missing method … (…, SByte)” diagnostics.
Changes:
- Update
JniSignatureHelper.EncodeClrTypeForCallbackto encode JNIbooleanasSystem.Booleaninstead ofSystem.SByte. - Update unit tests to assert the corrected callback signature encoding while preserving
bytefor the UCO ABI. - Strengthen/clarify regression-test comments around the prior failure mode (unresolvable MemberRef → ILC diagnostic).
Show a summary per file
| File | Description |
|---|---|
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs |
Fixes callback MemberRef boolean encoding to System.Boolean, matching MCW n_* signatures. |
tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs |
Updates assertions and regression tests to validate byte UCO ABI vs bool callback MemberRef encoding. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 0
|
/review |
1 similar comment
|
/review |
The trimmable typemap generator emits, for each Java peer marshal method, a UCO forwarder that calls the MCW-generated `n_*` callback via a cross-assembly MemberRef. For JNI boolean (`Z`) and char (`C`) the CLR type of that callback is *not* fixed: java-interop #1296 ("Avoid non-blittable types in native callback methods") changed the generator so `n_*` declares JNI boolean as blittable `sbyte` (was `bool`) and char as `ushort` (was `char`). Bindings therefore differ by the generator version that compiled them: modern Mono.Android uses `sbyte`/`ushort`, while older prebuilt bindings (e.g. Xamarin.AndroidX.Fragment) still declare `bool`/`char`. A single hardcoded choice is wrong for half the ecosystem: emitting a MemberRef whose boolean/char type doesn't match the real `n_*` method cannot bind under ILC, which then reports ILC: Method '..._Proxy.n_*_uco_*' will always throw because: Missing method '... n_* (...)' and replaces the forwarder with a throw -- breaking those interface listener callbacks under NativeAOT (observed as a native SIGSEGV when a boolean SSL validation callback fires mid-handshake in the CoreCLRTrimmable device tests). This supersedes the earlier `sbyte -> bool` flip, which only moved the breakage from AndroidX to Mono.Android. Fix: match each callback MemberRef to its real target. The scanner resolves the actual `n_*` static method (on the connector-declared invoker type, or the method's own declaring type) and captures its parameter/return CLR type names; the emitter mirrors that signature exactly. Unambiguous kinds still encode from the JNI descriptor; the `[UnmanagedCallersOnly]` entry keeps its blittable `byte`/`ushort` ABI and the forwarder body is unchanged (bool, sbyte, byte, char, ushort are all int32 on the eval stack). When a boolean/char callback's `n_*` signature cannot be resolved from metadata the generator now fails with a clear error rather than guessing. Tests: TestFixtures gains real `n_*` methods -- TouchHandler models a pre-#1296 binding (`bool`) and IOnLongClickListenerInvoker a post-#1296 one (`sbyte`). New tests assert the callback MemberRef mirrors each real method (bool vs sbyte), that both encodings can coexist in one generated assembly, and that an unresolved boolean callback throws. EncodeClrTypeForCallback now rejects boolean/char (they must come from the captured signature). Fixes #11773. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…h capture - Remove the banned null-forgiving `!` operator in the callback-signature emitter; narrow the captured type-name locals with `is not null` instead. - Gate the base-hierarchy [Register] method/property n_* signature capture on HasAmbiguousCallbackType, matching AddMarshalMethod, so inherited non- boolean/char registrations don't pay for type resolution + method enumeration whose result is never used. No behavior change; all 600 TrimmableTypeMap generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
JNI char is ambiguous exactly like boolean (java-interop #1296 changed the n_* callback encoding from System.Char to blittable System.UInt16). Add fixtures and tests covering both eras so the char path is locked down the same way boolean is: - TouchHandler (pre-#1296) gains getKeyChar/setKeyChar with n_* declaring System.Char; new tests assert the callback MemberRef is Char (return and param) while the UCO entry keeps ushort. - New IOnKeyListener/IOnKeyListenerInvoker (post-#1296) declare n_OnKey_C with ushort, and KeyListenerImpl forwards to it via inheritance; a test asserts the callback MemberRef is UInt16. All 603 TrimmableTypeMap generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 Code Review — PR #11802
Verdict: No blocking issues found — the fix is well-targeted and the regression coverage is excellent. Not marking ✅ LGTM only because the MSBuild/device test lanes were still running at review time (the Linux > Build stage had already gone green, none failed).
What this change does (verified independently)
The trimmable typemap emits [UnmanagedCallersOnly] forwarders that call each MCW n_* callback through a cross-assembly MemberRef. For JNI boolean (Z) and char (C) the real n_* CLR type is ambiguous — pre-#1296 bindings declare bool/char, post-#1296 declare the blittable sbyte/ushort. The old code hard-coded one form, so the MemberRef failed to bind for half the ecosystem (SIGSEGV under NativeAOT / CoreCLR-Trimmable). The fix captures the real n_* param/return CLR type names from metadata (TryReadNativeCallbackSignature) and mirrors them exactly, failing fast (NativeCallbackSignatureUnresolved) when an ambiguous kind can't be resolved rather than guessing. I confirmed the root cause and that the fix addresses it at the root.
Strengths
- Correct blittable split: the UCO wrapper keeps the JNI ABI (
byte/ushort) while the callback ref mirrors the managed signature — and the forwarder body needs no conversions becausebool/sbyte/byte/char/ushortare all int32-family on the IL evaluation stack. - Fail-fast over guessing: ambiguous-but-unresolvable signatures throw a clear, actionable error instead of emitting a subtly-wrong ref that would fault at runtime.
- Thorough, real tests: pre-
#1296(bool/charviaTouchHandler), post-#1296(sbyte/ushortvia theIOn*ListenerInvokerfixtures), mixed coexistence, and the unresolved→throw path are all covered — and theTouchHandlertests exercise the actual scanner capture (viaFindFixtureByJavaName), not hand-built peers. EncodeClrTypeNamesurface is safe: I verified generatedn_*methods declare enums asint(NativeType/JniName = I) and objects asIntPtr, so the switch covers every type real callbacks use;default: throwis a genuine safety net, not a latent bug.
Suggestion
- 💡 One optional build-time hardening on
TryReadNativeCallbackSignature(posted inline) — verify the leadingIntPtrpair when matching then_*method by name + arity.
Nice, well-reasoned fix with strong regression coverage. Once the test lanes finish green this looks ready.
Generated by Android PR Reviewer for #11802 · 470.8 AIC · ⌖ 27 AIC · ⊞ 6.8K
Comment /review to run again
… unreadable
Fixes an XAGTT7009 build regression introduced by the metadata-driven callback
signature capture: real trimmable-typemap builds failed with e.g.
error XAGTT7009: ... cannot emit the native callback reference
'Org.XmlPull.V1.IXmlPullParserInvoker.n_IsEmptyElementTag' (JNI '()Z')
because ... the real 'n_*' method's signature could not be resolved
Root cause (confirmed from the CI build.binlog): GenerateTrimmableTypeMap scans
the compile-time *reference* assemblies (Microsoft.Android.Ref/.../Mono.Android.dll
was the only Mono.Android in the log; no runtime/impl assembly is fed). Reference
assemblies strip the `private static n_*` methods, so the capture added in this PR
returns null for every framework boolean/char callback, and the previous hard
`throw` then failed the build. The original code never read the n_* method — it
emitted the MemberRef by name from the connector metadata (present in the ref
assembly's [Register] attributes) — so this was a self-inflicted regression.
Fix: instead of throwing when the n_* signature can't be read, fall back to the
blittable encoding (JNI boolean -> sbyte, char -> ushort). This is correct, not a
blind guess: an unreadable n_* means a reference assembly, i.e. a framework binding
(Mono.Android/Java.Interop), which is always produced by the current post-#1296
generator and therefore uses sbyte/ushort — matching the runtime n_* (verified:
runtime Mono.Android n_IsEmptyElementTag is int8/sbyte). NuGet binding
implementation assemblies (e.g. AndroidX) still ship the real n_* in lib/, so their
signature is captured exactly, preserving the pre-#1296 `bool` fix for #11773.
- EncodeClrTypeForCallback: boolean -> sbyte, char -> ushort (blittable fallback),
used only when no captured signature is available.
- Emitter: use captured signature when present, else the blittable fallback; the
hard throw and its helper are removed.
- Tests: replace the throws-when-unresolved test with one asserting the sbyte
fallback; update the fallback encoding theory (boolean->0x04, char->0x07).
Verified locally that a concrete peer scanned from the ref Mono.Android now
generates n_IsEmptyElementTag as System.SByte without throwing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address review: TryReadNativeCallbackSignature matched the n_* method by name + arity, then sliced ParameterTypes[i+2] assuming the first two params are the (IntPtr jnienv, IntPtr native__this) pair. Also assert those two leading params are System.IntPtr before slicing, so a same-named static method with a coincidentally-matching arity can never feed the wrong native param types into the callback MemberRef. Cheap build-time invariant check; all real n_* callbacks begin with the IntPtr pair, so behavior is unchanged (603 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- IsAmbiguousCallbackKind doc said the callback ref "must be built from the real n_* signature rather than guessed"; that predates the blittable fallback. Reword to match shipped behavior: captured when available, else fall back to blittable. - Add Generate_UnresolvedCharCallback_FallsBackToUInt16 so the unresolved->blittable end-to-end path is covered for char (ushort), mirroring the boolean (sbyte) test. All 604 TrimmableTypeMap generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes #11773.
Problem
Under the trimmable typemap (NativeAOT), binding/AndroidX interface listener callbacks could fail with:
ILC then replaces the forwarder body with a
throw, so the callback never dispatches. At runtime this surfaces as a native SIGSEGV — e.g. theCoreCLRTrimmabledevice-test lane crashed inAndroidMessageHandlerTests.ServerCertificateCustomValidationCallback_*(a boolean-returning SSL validation callback) mid-handshake, while the same tests passed underCoreCLR.Root cause
The generator emits, per Java peer marshal method, a
[UnmanagedCallersOnly]UCO forwarder that calls the MCW-generatedn_*callback via a cross-assemblyMemberRef. For JNI boolean (Z) and char (C), the CLR type of the realn_*method is not fixed:n_*declares JNI boolean as blittablesbyte(wasbool) and char asushort(waschar).sbyte/ushort, while older prebuilt bindings still usebool/char.Verified empirically:
Xamarin.AndroidX.Fragment1.8.3.1 shipsbool n_IsCancelable(...)/void n_SetCancelable_Z(..., bool)(zerosbytein the assembly), whereas current Mono.Android usessbyte.Any single hardcoded choice is therefore wrong for half the ecosystem — the emitted
MemberReffails to bind against bindings compiled by the "other" generator. (This supersedes the earliersbyte → boolflip, which merely moved the breakage from AndroidX to Mono.Android and caused the crash above.)Fix
Match each callback
MemberRefto its own real target method, per binding:n_*static method (on the connector-declared invoker type, or the method's own declaring type) and captures its parameter/return CLR type names.MemberRefto mirror that captured signature exactly. Unambiguous kinds still encode from the JNI descriptor.[UnmanagedCallersOnly]entry keeps its blittablebyte/ushortABI, and the forwarder body is unchanged —bool,sbyte,byte,char,ushortare allint32on the evaluation stack, so no conversions are needed.n_*signature cannot be resolved from metadata, generation now fails with a clear error instead of guessing.Tests
TestFixturesgains realn_*methods:TouchHandlermodels a pre-Encountering crash in 'Hello' test case #1296 binding (bool), andIOnLongClickListenerInvokera post-Encountering crash in 'Hello' test case #1296 binding (sbyte).MemberRefmirrors each real method (boolvssbyte), that both encodings coexist in one generated assembly, and that an unresolved boolean callback throws.EncodeClrTypeForCallbacknow rejects boolean/char (they must come from the captured signature); added coverage forEncodeClrTypeName.Microsoft.Android.Sdk.TrimmableTypeMap.Testspass.